主题
HITL = Human-In-The-Loop,就是在危险工具执行前插入一道「人工审批」——当 Agent 想写文件、执行命令这种有破坏性的操作时,先停下来把操作弹给用户确认,用户可以批准、拒绝、改参数、或全部放行。本质是给自主运行的 Agent 加一道安全闸门。
Map<String, Tool> 注册全部工具,Tool 是 record(name, description, JSON Schema parameters, executor),构造时分 9 组注册。内置 16 个工具:read_file(offset/limit,2000 行上限)、write_file(5MB 上限)、list_dir、glob_files、grep_code(ripgrep 优先 + Java 兜底,结果带 suggested_reads 直接告诉模型接下来该 read 哪些文件)、execute_command、create_project、search_code(RAG 语义检索)、web_search、web_fetch、browser 三件套、load_skill、save_memory、revert_turn。
描述即路由:模型选工具完全靠 name + description + JSON Schema——工具描述的措辞质量直接决定命中率(与八股「描述定生死」同源)。
安全四层(明确拒绝沙箱叙事)
| 层 | 机制 | 定位 |
|---|---|---|
| 路径围栏 | PathGuard.resolveSafe:项目根围栏、..、符号链接 | 所有路径类工具必经 |
| 命令黑名单 | CommandGuard 9 条正则(sudo/rm -rf 全盘/mkfs/dd of=/dev/fork bomb/curl|sh/chmod 777/shutdown)快失败 | 辅助 HITL 减少弹窗,源码自认「黑名单是反模式」 |
| 人工审批 | HitlToolRegistry 装饰器:危险工具与所有 mcp__ 工具前插审批(5 种决策) | 危险操作人兜底 |
| 审计 | AuditLog 按天 JSONL(outcome=allow|deny|error,approver 标注) | 事后可追溯 |
命令执行统一 60 秒超时 + 8KB 输出截断 + destroyForcibly。定位诚实:不是沙箱,是「辅助人做对决策」的四层防线。
HITL 审批流程
一条链下来分四步。
第一步:判断要不要审批(ApprovalPolicy)。 这是静态规则,一个工具名集合 DANGEROUS_TOOLS 里只有四个:write_file / execute_command / create_project / revert_turn。外加一条规则:所有 mcp__ 前缀的工具默认都要审批(因为 MCP 来自外部 server,不确定它干什么)。反过来,read_file / grep_code / search_code 这些只读操作无副作用,不需要审批。
第二步:拦截(HitlToolRegistry)。 它继承 ToolRegistry,覆写了 executeToolOutput 这一个方法。执行流程是:HITL 未启用、或工具不需要审批 → 直接走父类执行(零额外开销);需要审批 → 先查浏览器敏感页面 → 查有没有「全部放行」记录 → 都没有才进入显式审批。
第三步:交互收集决策(HitlHandler)。 终端实现里弹一个审批框,展示工具名、危险等级、风险说明、参数,然后等用户按键。
第四步:按决策执行。 六种决策:
y批准 → 用原参数执行;a全部放行 → 本会话后续同类工具自动通过;n拒绝 → 返回[HITL] 操作已被拒绝给 LLM,让它换方案;s跳过 → 跳过这一步;m修改参数 → 用户输入新的 JSON 参数后执行。
【主动抛深度点 1:拦截链的分层,HITL 不是最终防线】 这里有个容易误解的点:HITL 通过不代表就能执行。完整的拦截顺序是 HitlToolRegistry → ToolRegistry → PathGuard/CommandGuard。HITL 只是「人」这一层审批,后面还有策略硬规则——PathGuard 强制路径在项目根内、CommandGuard 命令黑名单。关键约束是:用户无法批准一个策略拒绝的请求。也就是说,人只能「放行」策略允许范围内的操作,不能凌驾于硬规则之上。反过来,策略这层是兜底的,HITL 关了也不会裸奔。
【主动抛深度点 2:fail-safe 的保守设计】 所有「异常情况」都往「拒绝」方向倒,而不是默认放行:输入无法识别时重新提示而非放行;连续 5 次无效输入保守判拒绝;读取输入失败、输入流关闭也都保守拒绝。原则是「拿不准就拦」,因为误放行一个破坏性操作的代价,远大于多问一次。
怎么判断哪些工具需要审批?
静态规则,不是 LLM 判断。一个 DANGEROUS_TOOLS 集合显式列出 write_file / execute_command / create_project / revert_turn,再加上「所有 mcp__ 前缀工具默认审批」。判断逻辑就一行 DANGEROUS_TOOLS.contains(name) || isMcpTool(name)。为什么用静态白名单而不是让模型决定?因为安全规则要确定、可审计,不能交给一个不确定的模型。
HITL 和 PathGuard/CommandGuard 什么关系?审批通过了就一定能执行吗?
不能。HITL 在前、策略在后,两层叠加。HITL 通过后,doExecuteTool 里 PathGuard / CommandGuard 还会再拦一次。而且用户无法批准一个策略拒绝的请求——策略是硬规则,HITL 是「人」这一层软审批。设计意图是:人管「要不要做」,策略管「能不能做」,两者不互相替代。
审批决策有哪些?『修改参数』怎么实现?
六种:批准、全部放行(工具维度)、全部放行(MCP server 维度)、拒绝、修改参数、跳过。修改参数是让用户输入新的 JSON 参数,用 Jackson readTree 校验合法性,非法就退回主菜单,合法就返回 MODIFIED 决策,执行时用 effectiveArguments 取用户改过的参数替换原始参数。
HITL 未启用时有什么额外开销吗?
几乎没有。HitlToolRegistry 覆写 executeToolOutput 后,第一行就判断 !hitlHandler.isEnabled || !requiresApproval(name),不满足直接 super.doExecuteTool(...)。所以未启用时就是一次布尔判断的开销,和普通 ToolRegistry 行为完全一致。这也是为什么它做成「继承 + 覆写」而不是「在 ToolRegistry 里塞一堆 if」。
多 Agent 并行执行时,审批会串吗?
不会。TerminalHitlHandler.requestApproval 整体 synchronized,保证同一时刻只有一个审批提示在活跃,避免多个 Agent 并行时 stdout 串扰和 stdin 争抢——就是串行化审批。代价是并行任务到审批这步会排队,但换来交互不混乱,这是值得的取舍。
追问补强|每条都审批,用户不烦吗?
分级减负,不裸奔也不失控:单工具可"全部放行"(工具维度),MCP server 可整服放行(server 维度),读类工具本来就不审批;且放行之后 PathGuard/CommandGuard 仍然生效——放行的是"不再逐条问你",不是"随便它干"。浏览器模式切换等信任边界变化会清空放行状态、重新收紧;微信等非交互渠道反方向收紧到只读白名单。弹窗量是用户用放行粒度自己调出来的,安全下限由围栏兜底。
追问补强|审计日志记什么、怎么防泄密?
按天滚动 JSONL,每条记工具名、参数、结果(allow/deny/error)和 approver(hitl/policy/none),写入前做敏感信息脱敏(API key/token 类值打码),避免审计文件本身变成泄密源。HITL 审批与策略拦截共用同一份审计文件,事后可完整回放"谁在什么时候放行了什么"。
追问补强|CommandGuard 正则黑名单怎么被绕过?为什么不担心?
绕过面明摆着:base64 解码执行、变量拼接、sh -c 嵌套。所以定位就是"快速失败的第一道网"——拦常识性危险(sudo、rm -rf /、dd of=/dev/、curl|sh),不做完备性承诺;边界保证在 PathGuard(文件系统)和 HITL(人审),深度防御各层职责不同,黑名单只求把明显危险在便宜处拦掉。诚实承认这层可绕过是加分项。
源码锚点(读码定位用,面试不主动讲)
ApprovalPolicy.java:18HitlToolRegistry.java:36TerminalHitlHandler.java:97ApprovalResult.java:19ApprovalResult.java:82TerminalHitlHandler.java:114-119TerminalHitlHandler.java:71